Domina la librería unittest.mock de Python. Una inmersión profunda en dobles de prueba, objetos mock, stubs, fakes y el decorador patch para pruebas unitarias robustas y aisladas.
Objetos Mock de Python: Una Guía Completa para la Implementación de Dobles de Prueba
En el mundo del desarrollo de software moderno, escribir código es solo la mitad de la batalla. Asegurar que el código sea confiable, robusto y funcione como se espera es la otra mitad, igualmente crítica. Aquí es donde entran en juego las pruebas automatizadas. Las pruebas unitarias, en particular, son una práctica fundamental que implica probar componentes individuales o 'unidades' de una aplicación de forma aislada. Sin embargo, este aislamiento a menudo es más fácil de decir que de hacer. Las aplicaciones del mundo real son redes complejas de objetos, servicios y sistemas externos interconectados. ¿Cómo se puede probar una sola función si depende de una base de datos, una API de terceros u otra parte compleja de su sistema?
La respuesta radica en una técnica poderosa: el uso de Dobles de Prueba. Y en el ecosistema de Python, la herramienta principal para crearlos es la versátil e indispensable librería unittest.mock. Esta guía lo llevará a una inmersión profunda en el mundo de los mocks y los dobles de prueba en Python. Exploraremos el 'por qué' detrás de ellos, desmitificaremos los diferentes tipos y proporcionaremos ejemplos prácticos del mundo real utilizando unittest.mock para ayudarlo a escribir pruebas más limpias, rápidas y efectivas.
¿Qué son los Dobles de Prueba y por qué los Necesitamos?
Imagine que está construyendo una función que recupera el perfil de un usuario de la base de datos de su empresa y luego lo formatea. La firma de la función podría verse así: get_formatted_user_profile(user_id, db_connection).
Para probar esta función, se enfrenta a varios desafíos:
- Dependencia de un Sistema en Vivo: Su prueba necesitaría una base de datos en ejecución. Esto hace que las pruebas sean lentas, complejas de configurar y dependientes del estado y la disponibilidad de un sistema externo.
- Imprevisibilidad: Los datos en la base de datos podrían cambiar, lo que provocaría que su prueba falle incluso si su lógica de formato es correcta. Esto hace que las pruebas sean 'inestables' o no deterministas.
- Dificultad para Probar Casos Límite: ¿Cómo probaría lo que sucede si falla la conexión de la base de datos, o si devuelve un usuario al que le faltan algunos datos? Simular estos escenarios específicos con una base de datos real puede ser increíblemente difícil.
Un Doble de Prueba es un término genérico para cualquier objeto que sustituye a un objeto real durante una prueba. Al reemplazar la db_connection real con un doble de prueba, podemos cortar la dependencia de la base de datos real y tomar el control total del entorno de prueba.
El uso de dobles de prueba proporciona varios beneficios clave:
- Aislamiento: Le permiten probar su unidad de código (por ejemplo, la lógica de formato) en completo aislamiento de sus dependencias (por ejemplo, la base de datos). Si la prueba falla, sabe que el problema está en la unidad que se está probando, no en otro lugar.
- Velocidad: Reemplazar operaciones lentas como solicitudes de red o consultas de bases de datos con un doble de prueba en memoria hace que su conjunto de pruebas se ejecute dramáticamente más rápido. Las pruebas rápidas se ejecutan con más frecuencia, lo que conduce a un ciclo de retroalimentación más ajustado para los desarrolladores.
- Determinismo: Puede configurar el doble de prueba para que devuelva datos predecibles cada vez que se ejecuta la prueba. Esto elimina las pruebas inestables y garantiza que una prueba fallida indique un problema genuino.
- Capacidad para Probar Casos Límite: Puede configurar fácilmente un doble para simular condiciones de error, como generar una
ConnectionErroro devolver datos vacíos, lo que le permite verificar que su código maneja estas situaciones con elegancia.
La Taxonomía de los Dobles de Prueba: Más Allá de Solo "Mocks"
Si bien los desarrolladores a menudo usan el término "mock" genéricamente para referirse a cualquier doble de prueba, es útil comprender la terminología más precisa acuñada por Gerard Meszaros en su libro "xUnit Test Patterns". Conocer estas distinciones le ayuda a pensar con mayor claridad sobre lo que está tratando de lograr en su prueba.
1. Dummy
Un objeto Dummy es el doble de prueba más simple. Se pasa para llenar una lista de parámetros, pero en realidad nunca se usa. Sus métodos normalmente no se llaman. Usa un dummy cuando necesita proporcionar un argumento a un método, pero no le importa el comportamiento de ese argumento en el contexto de la prueba específica.
Ejemplo: Si una función requiere un objeto 'logger' pero a su prueba no le preocupa lo que se registra, podría pasar un objeto dummy.
2. Fake
Un objeto Fake tiene una implementación funcional, pero es una versión mucho más simple del objeto de producción. No utiliza recursos externos y sustituye una implementación ligera por una pesada. El ejemplo clásico es una base de datos en memoria que reemplaza una conexión de base de datos real. En realidad funciona; puede agregar datos y leer datos de ella, pero es solo un simple diccionario o lista debajo del capó.
3. Stub
Un Stub proporciona respuestas preprogramadas y "enlatadas" a las llamadas de métodos realizadas durante una prueba. Se utiliza cuando necesita que su código reciba datos específicos de una dependencia. Por ejemplo, podría simular un método como api_client.get_user(user_id=123) para que siempre devuelva un diccionario de usuario específico, sin realizar realmente una llamada a la API.
4. Spy
Un Spy es un stub que también registra información sobre cómo se llamó. Por ejemplo, podría registrar la cantidad de veces que se llamó a un método o los argumentos que se le pasaron. Esto le permite "espiar" la interacción entre su código y su dependencia y luego hacer afirmaciones sobre esa interacción después del hecho.
5. Mock
Un Mock es el tipo de doble de prueba más 'consciente'. Es un objeto que está preprogramado con expectativas de qué métodos se llamarán, con qué argumentos y en qué orden. Una prueba que usa un objeto mock normalmente fallará no solo si el código bajo prueba produce el resultado incorrecto, sino también si no interactúa con el mock de la manera precisamente esperada. Los mocks son excelentes para la verificación del comportamiento: asegurar que ocurrió una secuencia específica de acciones.
La librería unittest.mock de Python proporciona una sola clase poderosa que puede actuar como Stub, Spy o Mock, según cómo la use.
Presentamos la Potencia de Python: La Librería `unittest.mock`
Parte de la librería estándar de Python desde la versión 3.3, unittest.mock es la solución canónica para crear dobles de prueba. Su flexibilidad y potencia la convierten en una herramienta esencial para cualquier desarrollador de Python serio. Si está utilizando una versión anterior de Python, puede instalar la librería retroportada a través de pip: pip install mock.
El núcleo de la librería gira en torno a dos clases clave: Mock y su hermano más capaz, MagicMock. Estos objetos están diseñados para ser increíblemente flexibles, creando atributos y métodos sobre la marcha a medida que accede a ellos.
Inmersión Profunda: Las Clases `Mock` y `MagicMock`
El Objeto `Mock`
Un objeto `Mock` es un camaleón. Puede crear uno e inmediatamente responderá a cualquier acceso de atributo o llamada de método, devolviendo otro objeto Mock de forma predeterminada. Esto le permite encadenar llamadas fácilmente durante la configuración.
# En un archivo de prueba...
from unittest.mock import Mock
# Crear un objeto mock
mock_api = Mock()
# Acceder a un atributo lo crea y devuelve otro mock
print(mock_api.users)
# Output: <Mock name='mock.users' id='...'>
# Llamar a un método también devuelve un mock de forma predeterminada
print(mock_api.users.get(id=1))
# Output: <Mock name='mock.users.get()' id='...'>
Este comportamiento predeterminado no es muy útil para las pruebas. El verdadero poder proviene de configurar el mock para que se comporte como el objeto que está reemplazando.
Configuración de Valores de Retorno y Efectos Secundarios
Puede decirle a un método mock qué devolver usando el atributo return_value. Así es como se crea un Stub.
from unittest.mock import Mock
# Crear un mock para un servicio de datos
mock_service = Mock()
# Configurar el valor de retorno para una llamada de método
mock_service.get_data.return_value = {'id': 1, 'name': 'Test Data'}
# Ahora, cuando lo llamamos, obtenemos nuestro valor configurado
result = mock_service.get_data()
print(result)
# Output: {'id': 1, 'name': 'Test Data'}
Para simular errores, puede usar el atributo side_effect. Esto es perfecto para probar el manejo de errores de su código.
from unittest.mock import Mock
mock_service = Mock()
# Configurar el método para que genere una excepción
mock_service.get_data.side_effect = ConnectionError("Failed to connect to service")
# Llamar al método ahora generará la excepción
try:
mock_service.get_data()
except ConnectionError as e:
print(e)
# Output: Failed to connect to service
Métodos de Aserto para la Verificación
Los objetos Mock también actúan como Spies y Mocks al registrar cómo se utilizan. Luego, puede usar un conjunto de métodos de aserto integrados para verificar estas interacciones.
mock_object.method.assert_called(): Afirma que el método se llamó al menos una vez.mock_object.method.assert_called_once(): Afirma que el método se llamó exactamente una vez.mock_object.method.assert_called_with(*args, **kwargs): Afirma que el método se llamó por última vez con los argumentos especificados.mock_object.method.assert_any_call(*args, **kwargs): Afirma que el método se llamó con estos argumentos en algún momento.mock_object.method.assert_not_called(): Afirma que el método nunca se llamó.mock_object.call_count: Una propiedad entera que le dice cuántas veces se llamó al método.
from unittest.mock import Mock
mock_notifier = Mock()
# Imagine que esta es nuestra función bajo prueba
def process_and_notify(data, notifier):
if data.get('critical'):
notifier.send_alert(message="Critical event occurred!")
# Caso de prueba 1: Datos críticos
process_and_notify({'critical': True}, mock_notifier)
mock_notifier.send_alert.assert_called_once_with(message="Critical event occurred!")
# Restablecer el mock para la siguiente prueba
mock_notifier.reset_mock()
# Caso de prueba 2: Datos no críticos
process_and_notify({'critical': False}, mock_notifier)
mock_notifier.send_alert.assert_not_called()
El Objeto `MagicMock`
Un `MagicMock` es una subclase de `Mock` con una diferencia clave: tiene implementaciones predeterminadas para la mayoría de los métodos "mágicos" o "dunder" de Python (por ejemplo, __len__, __str__, __iter__). Si intenta usar un `Mock` normal en un contexto que requiere uno de estos métodos, obtendrá un error.
from unittest.mock import Mock, MagicMock
# Usando un Mock regular
mock_list = Mock()
try:
len(mock_list)
except TypeError as e:
print(e) # Output: 'Mock' object has no len()
# Usando un MagicMock
magic_mock_list = MagicMock()
print(len(magic_mock_list)) # Output: 0 (de forma predeterminada)
# También podemos configurar el valor de retorno del método mágico
magic_mock_list.__len__.return_value = 100
print(len(magic_mock_list)) # Output: 100
Regla general: Comience con `MagicMock`. Generalmente es más seguro y cubre más casos de uso, como simular objetos que se usan en bucles for (que requieren __iter__) o sentencias with (que requieren __enter__ y __exit__).
Implementación Práctica: El Decorador y Administrador de Contexto `patch`
Crear un mock es una cosa, pero ¿cómo hace que su código lo use en lugar del objeto real? Aquí es donde entra `patch`. `patch` es una herramienta poderosa en `unittest.mock` que reemplaza temporalmente un objeto de destino con un mock durante la duración de una prueba.
`@patch` como Decorador
La forma más común de usar `patch` es como un decorador en su método de prueba. Debe proporcionar la ruta de cadena al objeto que desea reemplazar.
Digamos que tenemos una función que obtiene datos de una API web utilizando la popular librería `requests`:
# en el archivo: my_app/data_fetcher.py
import requests
def get_user_data(user_id):
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status() # Generar una excepción para códigos de estado incorrectos
return response.json()
Queremos probar esta función sin realizar una llamada de red real. Podemos parchear `requests.get`:
# en el archivo: tests/test_data_fetcher.py
import unittest
from unittest.mock import patch, Mock
from my_app.data_fetcher import get_user_data
class TestDataFetcher(unittest.TestCase):
@patch('my_app.data_fetcher.requests.get')
def test_get_user_data_success(self, mock_get):
"""Prueba la obtención de datos exitosa."""
# Configurar el mock para simular una respuesta de API exitosa
mock_response = Mock()
mock_response.json.return_value = {'id': 1, 'name': 'John Doe'}
mock_response.raise_for_status.return_value = None # No hacer nada en caso de éxito
mock_get.return_value = mock_response
# Llamar a nuestra función
user_data = get_user_data(1)
# Afirmar que nuestra función hizo la llamada de API correcta
mock_get.assert_called_once_with('https://api.example.com/users/1')
# Afirmar que nuestra función devolvió los datos esperados
self.assertEqual(user_data, {'id': 1, 'name': 'John Doe'})
Observe cómo `patch` crea un `MagicMock` y lo pasa a nuestro método de prueba como el argumento `mock_get`. Dentro de la prueba, cualquier llamada a `requests.get` dentro de `my_app.data_fetcher` se redirige a nuestro objeto mock.
`patch` como Administrador de Contexto
A veces, solo necesita parchear algo durante una pequeña parte de una prueba. Usar `patch` como administrador de contexto con una declaración `with` es perfecto para esto.
# en el archivo: tests/test_data_fetcher.py
import unittest
from unittest.mock import patch, Mock
from my_app.data_fetcher import get_user_data
class TestDataFetcher(unittest.TestCase):
def test_get_user_data_with_context_manager(self):
"""Prueba el uso de patch como administrador de contexto."""
with patch('my_app.data_fetcher.requests.get') as mock_get:
# Configurar el mock dentro del bloque 'with'
mock_response = Mock()
mock_response.json.return_value = {'id': 2, 'name': 'Jane Doe'}
mock_get.return_value = mock_response
user_data = get_user_data(2)
mock_get.assert_called_once_with('https://api.example.com/users/2')
self.assertEqual(user_data, {'id': 2, 'name': 'Jane Doe'})
# Fuera del bloque 'with', requests.get vuelve a su estado original
Un Concepto Crucial: ¿Dónde Parchear?
Esta es la fuente más común de confusión al usar `patch`. La regla es: Debe parchear el objeto donde se busca, no donde se define.
Ilustremos con un ejemplo. Supongamos que tenemos dos archivos:
# en el archivo: services.py
class Database:
def connect(self):
# ... lógica de conexión compleja ...
return "REAL_CONNECTION"
# en el archivo: main_app.py
from services import Database
def start_app():
db = Database()
connection = db.connect()
print(f"Got connection: {connection}")
return connection
Ahora, queremos probar `start_app` en `main_app.py` sin crear un objeto `Database` real. Un error común es intentar parchear `services.Database`.
# en el archivo: test_main_app.py
import unittest
from unittest.mock import patch
from main_app import start_app
class TestApp(unittest.TestCase):
# ¡ESTA ES LA FORMA INCORRECTA DE PARCHEAR!
@patch('services.Database')
def test_start_app_incorrectly(self, mock_db):
start_app()
# ¡Esta prueba seguirá usando la clase Database REAL!
# ¡ESTA ES LA FORMA CORRECTA DE PARCHEAR!
@patch('main_app.Database')
def test_start_app_correctly(self, mock_db_class):
# Estamos parcheando 'Database' en el espacio de nombres 'main_app'
# Configurar la instancia mock que se creará
mock_instance = mock_db_class.return_value
mock_instance.connect.return_value = "MOCKED_CONNECTION"
connection = start_app()
# Afirmar que se usó nuestro mock
mock_db_class.assert_called_once() # ¿Se instanció la clase?
mock_instance.connect.assert_called_once() # ¿Se llamó al método connect?
self.assertEqual(connection, "MOCKED_CONNECTION")
¿Por qué falla la primera prueba? Porque `main_app.py` ejecuta `from services import Database`. Esto importa la clase `Database` al espacio de nombres del módulo `main_app`. Cuando se ejecuta `start_app`, busca `Database` dentro de su propio módulo (`main_app`). Parchear `services.Database` lo cambia en el módulo `services`, pero `main_app` ya tiene su propia referencia a la clase original. El enfoque correcto es parchear `main_app.Database`, que es el nombre que realmente usa el código bajo prueba.
Técnicas Avanzadas de Mocking
`spec` y `autospec`: Haciendo los Mocks Más Seguros
Un `MagicMock` estándar tiene una posible desventaja: le permitirá llamar a cualquier método con cualquier argumento, incluso si ese método no existe en el objeto real. Esto puede llevar a pruebas que pasan pero ocultan problemas reales, como errores tipográficos en los nombres de los métodos o cambios en la API de un objeto real.
# Clase real
class Notifier:
def send_message(self, text):
# ... envía mensaje ...
pass
# Una prueba con un error tipográfico
from unittest.mock import MagicMock
mock_notifier = MagicMock()
# ¡Ups, un error tipográfico! El método real es send_message
mock_notifier.send_mesage("hello") # ¡No se genera ningún error!
mock_notifier.send_mesage.assert_called_with("hello") # ¡Esta afirmación pasa!
# Nuestra prueba es verde, pero el código de producción fallaría.
Para evitar esto, `unittest.mock` proporciona los argumentos `spec` y `autospec`.
- `spec=SomeClass`: Esto configura el mock para que tenga la misma API que `SomeClass`. Si intenta acceder a un método o atributo que no existe en la clase real, se generará un `AttributeError`.
- `autospec=True` (o `autospec=SomeClass`): Esto es aún más potente. Actúa como `spec`, pero también comprueba la firma de llamada de cualquier método simulado. Si llama a un método con el número o los nombres de argumentos incorrectos, generará un `TypeError`, tal como lo haría el objeto real.
from unittest.mock import create_autospec
# Crear un mock que tenga la misma interfaz que nuestra clase Notifier
spec_notifier = create_autospec(Notifier)
try:
# Esto fallará inmediatamente debido al error tipográfico
spec_notifier.send_mesage("hello")
except AttributeError as e:
print(e) # Output: Mock object has no attribute 'send_mesage'
try:
# Esto fallará porque la firma es incorrecta (sin la palabra clave 'text')
spec_notifier.send_message("hello")
except TypeError as e:
print(e) # Output: missing a required argument: 'text'
# Esta es la forma correcta de llamarlo
spec_notifier.send_message(text="hello") # ¡Esto funciona!
spec_notifier.send_message.assert_called_once_with(text="hello")
Mejor práctica: Siempre use `autospec=True` al parchear. Hace que sus pruebas sean más robustas y menos frágiles. `@patch('path.to.thing', autospec=True)`.
Ejemplo del Mundo Real: Probar un Servicio de Procesamiento de Datos
Unamos todo con un ejemplo más completo. Tenemos un `ReportGenerator` que depende de una base de datos y un sistema de archivos.
# en el archivo: app/services.py
class DatabaseConnector:
def get_sales_data(self, start_date, end_date):
# En realidad, esto consultaría una base de datos
raise NotImplementedError("This should not be called in tests")
class FileSaver:
def save_report(self, path, content):
# En realidad, esto escribiría en un archivo
raise NotImplementedError("This should not be called in tests")
# en el archivo: app/reports.py
from .services import DatabaseConnector, FileSaver
class ReportGenerator:
def __init__(self):
self.db_connector = DatabaseConnector()
self.file_saver = FileSaver()
def generate_sales_report(self, start_date, end_date, output_path):
"""Obtiene los datos de ventas y guarda un informe formateado."""
raw_data = self.db_connector.get_sales_data(start_date, end_date)
if not raw_data:
report_content = "No sales data for this period."
else:
total_sales = sum(item['amount'] for item in raw_data)
report_content = f"Total Sales from {start_date} to {end_date}: ${total_sales:.2f}"
self.file_saver.save_report(path=output_path, content=report_content)
return True
Ahora, escribamos una prueba unitaria para `ReportGenerator.generate_sales_report` que simule sus dependencias.
# en el archivo: tests/test_reports.py
import unittest
from datetime import date
from unittest.mock import patch, Mock
from app.reports import ReportGenerator
class TestReportGenerator(unittest.TestCase):
@patch('app.reports.FileSaver', autospec=True)
@patch('app.reports.DatabaseConnector', autospec=True)
def test_generate_sales_report_with_data(self, mock_db_connector_class, mock_file_saver_class):
"""Prueba la generación de informes cuando la base de datos devuelve datos."""
# Arrange: Configurar nuestros mocks
mock_db_instance = mock_db_connector_class.return_value
mock_file_saver_instance = mock_file_saver_class.return_value
# Configurar el mock de la base de datos para que devuelva algunos datos falsos (Stub)
fake_data = [
{'id': 1, 'amount': 100.50},
{'id': 2, 'amount': 75.00},
{'id': 3, 'amount': 25.25}
]
mock_db_instance.get_sales_data.return_value = fake_data
start = date(2023, 1, 1)
end = date(2023, 1, 31)
path = '/reports/sales_jan_2023.txt'
# Act: Crear una instancia de nuestra clase y llamar al método
generator = ReportGenerator()
result = generator.generate_sales_report(start, end, path)
# Assert: Verificar las interacciones y los resultados
# 1. ¿Se llamó a la base de datos correctamente?
mock_db_instance.get_sales_data.assert_called_once_with(start, end)
# 2. ¿Se llamó al guardador de archivos con el contenido correcto y calculado?
expected_content = "Total Sales from 2023-01-01 to 2023-01-31: $200.75"
mock_file_saver_instance.save_report.assert_called_once_with(
path=path,
content=expected_content
)
# 3. ¿Nuestro método devolvió el valor correcto?
self.assertTrue(result)
Esta prueba aísla perfectamente la lógica dentro de `generate_sales_report` de las complejidades de la base de datos y el sistema de archivos, al tiempo que verifica que interactúa con ellos correctamente.
Mejores Prácticas para un Mocking Eficaz
- Mantenga los Mocks Simples: Una prueba que requiere una configuración de mock muy compleja es a menudo una señal (un "olor a prueba") de que la unidad bajo prueba es demasiado compleja y puede estar violando el Principio de Responsabilidad Única. Considere la posibilidad de refactorizar el código de producción.
- Simule Colaboradores, No Todo: Solo debe simular los objetos con los que se comunica su unidad bajo prueba (sus colaboradores). No simule el objeto que está probando en sí mismo.
- Prefiera `autospec=True`: Como se mencionó, esto hace que sus pruebas sean más robustas al garantizar que la interfaz del mock coincida con la interfaz del objeto real. Esto ayuda a detectar problemas causados por la refactorización.
- Un Mock por Prueba (Idealmente): Una buena prueba unitaria se centra en un solo comportamiento o interacción. Si se encuentra simulando muchos objetos diferentes en una prueba, podría ser mejor dividirla en pruebas múltiples y más centradas.
- Sea Específico en Sus Aserciones: No se limite a verificar `mock.method.assert_called()`. Use `assert_called_with(...)` para asegurarse de que la interacción ocurrió con los datos correctos. Esto hace que sus pruebas sean más valiosas.
- Sus Pruebas Son Documentación: Use nombres claros y descriptivos para sus pruebas y objetos mock (por ejemplo, `mock_api_client`, `test_login_fails_on_network_error`). Esto deja claro el propósito de la prueba a otros desarrolladores.
Conclusión
Los dobles de prueba no son solo una herramienta para las pruebas; son una parte fundamental del diseño de software modular, mantenible y que se puede probar. Al reemplazar las dependencias reales con sustitutos controlados, puede crear un conjunto de pruebas que sea rápido, confiable y capaz de verificar cada rincón de la lógica de su aplicación.
La librería unittest.mock de Python proporciona un kit de herramientas de clase mundial para implementar estos patrones. Al dominar MagicMock, `patch` y la seguridad de `autospec`, desbloquea la capacidad de escribir pruebas unitarias verdaderamente aisladas. Esto le permite construir aplicaciones complejas con confianza, sabiendo que tiene una red de seguridad de pruebas precisas y específicas para detectar regresiones y validar nuevas características. Así que adelante, comience a parchear y cree aplicaciones Python más robustas hoy mismo.